# Copyright 2023 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

from __future__ import annotations

import atexit
import contextlib
import ctypes
import ctypes.util
import functools
import json
import logging
import plistlib
import re
import socket
import subprocess
import traceback as tb
from subprocess import SubprocessError
from typing import TYPE_CHECKING, Any, Final, Iterator, Optional, Type

import psutil
from typing_extensions import override

from crossbench import path as pth
from crossbench.parse import NumberParser
from crossbench.plt.posix import PosixPlatform, PosixVersion
from crossbench.plt.signals import MacOSSignals

if TYPE_CHECKING:
  from crossbench.plt.base import CPUFreqInfo
  from crossbench.plt.display_info import DisplayInfo
  from crossbench.plt.version import PlatformVersion

DISPLAY_NDRV_RE: Final[re.Pattern] = re.compile(
    "(?P<resX>[0-9]+) x (?P<resY>[0-9]+) @ (?P<freq>[0-9.]+)Hz")


def parse_display_ndrvs(spdisplays_ndrvs: dict) -> Iterator[DisplayInfo]:
  """
  Parses `system_profiler SPDisplaysDataType` output.
  "SPDisplaysDataType" : [
    {
      ...
      "spdisplays_ndrvs" : [
        {
          ...
          "_spdisplays_resolution" : "1728 x 1117 @ 60.00Hz",
          "spdisplays_ambient_brightness" : "spdisplays_no",
          "spdisplays_pixelresolution" : "spdisplays_3456x2234Retina"
          ...
        },
        {
          ...
          "_spdisplays_resolution" : "3360 x 1890 @ 30.00Hz",
          "spdisplays_pixelresolution" : "6720 x 3780",
          "spdisplays_resolution" : "3360 x 1890 @ 30.00Hz",
          ...
        }
      ],
      ...
    }
  ]
  """
  for spdisplay_ndrv in spdisplays_ndrvs:
    # Use virtual pixel resolution of the monitor:
    freq_str = spdisplay_ndrv.get("_spdisplays_resolution", "")
    if match := DISPLAY_NDRV_RE.search(freq_str):
      yield {
          "resolution": (NumberParser.positive_int(match.group("resX")),
                         NumberParser.positive_int(match.group("resY"))),
          "refresh_rate": NumberParser.positive_float(match.group("freq")),
      }


class MacOsVersion(PosixVersion):
  pass


class MacOSPlatform(PosixPlatform):
  SEARCH_PATHS: tuple[pth.AnyPath, ...] = (
      pth.AnyPosixPath("."),
      pth.AnyPosixPath("/Applications"),
      # TODO: support remote platforms
      pth.LocalPath.home() / "Applications",
  )

  LSAPPINFO_IN_FRONT_LINE_RE: Final = r".*\(in front\)\s*"
  LSAPPINFO_PID_LINE_RE: Final = r"\s*pid = ([0-9]+).*"

  @property
  @override
  def is_macos(self) -> bool:
    return True

  @property
  @override
  def name(self) -> str:
    return "macos"

  @property
  def signals(self) -> Type[MacOSSignals]:
    return MacOSSignals

  @functools.cached_property
  @override
  def version_str(self) -> str:
    return self.sh_stdout("sw_vers", "-productVersion").strip()

  @functools.cached_property
  @override
  def version(self) -> PlatformVersion:
    return MacOsVersion.parse(self.version_str)

  @functools.cached_property
  @override
  def model(self) -> str:
    return self.sh_stdout("sysctl", "-n", "hw.model").strip()

  @functools.cached_property
  @override
  def cpu(self) -> str:
    brand = self.sh_stdout("sysctl", "-n", "machdep.cpu.brand_string").strip()
    num_cores = self.cpu_cores(logical=True)
    return f"{brand} {num_cores} cores"

  @functools.lru_cache(maxsize=2)
  @override
  def cpu_cores(self, logical: bool) -> int:
    if self.is_local:
      return super().cpu_cores(logical)
    sysctl_name = "hw.logicalcpu_max" if logical else "hw.physicalcpu_max"
    cores = self.sh_stdout("sysctl", "-n", sysctl_name).strip()
    return int(cores)

  @property
  @override
  def is_battery_powered(self) -> bool:
    if self.is_local:
      return super().is_battery_powered
    return "Battery Power" in self.sh_stdout("pmset", "-g", "batt")

  def get_relative_cpu_speed(self) -> float:
    try:
      lines = self.sh_stdout("pmset", "-g", "therm").split()
      for index, line in enumerate(lines):
        if line == "CPU_Speed_Limit":
          return int(lines[index + 2]) / 100.0
    except SubprocessError:
      pass
    logging.debug("Could not get relative CPU speed: %s", tb.format_exc())
    return 1

  @functools.lru_cache(maxsize=1)
  @override
  def system_details(self) -> dict[str, Any]:
    details = super().system_details()
    details.update(self._macos_system_details())
    return details

  def _macos_system_details(self) -> dict[str, Any]:
    return {
        "system_profiler":
            self.sh_stdout("system_profiler", "SPHardwareDataType"),
        "sysctl_machdep_cpu":
            self.sh_stdout("sysctl", "machdep.cpu"),
        "sysctl_hw":
            self.sh_stdout("sysctl", "hw"),
    }

  @functools.lru_cache(maxsize=1)
  def display_details(self) -> tuple[DisplayInfo, ...]:
    display_info_raw = self.sh_stdout("system_profiler", "-json",
                                      "SPDisplaysDataType").strip()
    display_info = json.loads(display_info_raw)
    if spdisplays_data := display_info.get("SPDisplaysDataType"):
      if spdisplays_ndrvs := spdisplays_data[0].get("spdisplays_ndrvs"):
        return tuple(parse_display_ndrvs(spdisplays_ndrvs))
    return ()

  def display_resolution(self) -> tuple[int, int]:
    return self.display_details()[0]["resolution"]

  def _cpu_freq(self) -> Optional[CPUFreqInfo]:
    if self.is_remote:
      return super()._cpu_freq()
    # BUG(394337121): older macOs versions on arm segfault with python 3.11
    if self.is_arm64 and self.version_parts < (12, 0):
      return None
    try:
      return super()._cpu_freq()
    except FileNotFoundError as e:
      logging.debug("psutil.cpu_freq() failed (normal on macOS M1): %s", e)
      return None

  def _find_app_binary_path(self, app_path: pth.AnyPath) -> pth.AnyPath:
    assert app_path.suffix == ".app", f"Expected .app but got {app_path}"
    bin_path = app_path / "Contents" / "MacOS" / app_path.stem
    if self.exists(bin_path):
      return bin_path
    if not self.exists(bin_path.parent):
      raise ValueError(f"Binary does not exist: {bin_path}")
    self.assert_is_local()
    binaries = [
        path for path in self.iterdir(bin_path.parent) if self.is_file(path)
    ]
    if len(binaries) == 1:
      return binaries[0]
    # Fallback to read plist
    plist_path = app_path / "Contents" / "Info.plist"
    if not self.is_file(plist_path):
      raise ValueError(f"Could not find Info.plist in app bundle: {app_path}")
    # TODO: support remote platform
    with self.local_path(plist_path).open("rb") as f:
      plist = plistlib.load(f)
    bin_path = (
        app_path / "Contents" / "MacOS" /
        plist.get("CFBundleExecutable", app_path.stem))
    if self.is_file(bin_path):
      return bin_path
    if not binaries:
      raise ValueError(f"No binaries found in {app_path}")
    raise ValueError(f"Invalid number of binaries found: {binaries}")

  def search_binary(self, app_or_bin: pth.AnyPathLike) -> Optional[pth.AnyPath]:
    app_or_bin_path: pth.AnyPath = self.path(app_or_bin)
    if not app_or_bin_path.parts:
      raise ValueError("Got empty path")
    is_app = app_or_bin_path.suffix == ".app"
    if not is_app:
      # Look up basic binaries with `which` if possible.
      if result_path := self.which(app_or_bin_path):
        assert self.exists(result_path), f"{result_path} does not exist."
        return result_path
    if app_path := self.lookup_binary_override(app_or_bin_path):
      if app_path := self._validate_search_binary_candidate(is_app, app_path):
        return app_path
    for search_path in self.SEARCH_PATHS:
      # Recreate Path object for easier pyfakefs testing
      result_path = self.path(search_path) / app_or_bin_path
      if app_path := self._validate_search_binary_candidate(
          is_app, result_path):
        return app_path
    return None

  def _validate_search_binary_candidate(
      self, is_app: bool, result_path: pth.AnyPath) -> Optional[pth.AnyPath]:
    if not is_app:
      if self.is_file(result_path):
        return result_path
      return None
    if not self.is_dir(result_path):
      return None
    result_path = self._find_app_binary_path(result_path)
    if self.exists(result_path):
      return result_path
    return None

  def search_app(self, app_or_bin: pth.AnyPathLike) -> Optional[pth.AnyPath]:
    app_or_bin_path: pth.AnyPath = self.path(app_or_bin)
    if not app_or_bin_path.parts:
      raise ValueError("Got empty path")
    self.assert_is_local()
    if app_or_bin_path.suffix != ".app":
      raise ValueError("Expected app name with '.app' suffix, "
                       f"but got: '{app_or_bin_path.name}'")
    binary = self.search_binary(app_or_bin_path)
    if not binary:
      return None
    # input: /Applications/Safari.app/Contents/MacOS/Safari
    # output: /Applications/Safari.app
    app_path = binary.parents[2]
    assert app_path.suffix == ".app", f"Expected .app but got {app_path}"
    assert self.is_dir(app_path)
    return app_path

  @override
  def app_version(self, app_or_bin: pth.AnyPathLike) -> str:
    app_or_bin = self.path(app_or_bin)
    if not self.exists(app_or_bin):
      raise ValueError(f"Binary {app_or_bin} does not exist.")

    app_path = None
    for current in (app_or_bin, *app_or_bin.parents):
      if current.suffix == ".app" and current.stem == app_or_bin.stem:
        app_path = current
        break
    if not app_path:
      # Most likely just a cli tool"
      return self.sh_stdout(app_or_bin, "--version").strip()
    info_plist = app_path / "Contents/Info.plist"
    if self.exists(info_plist):
      plist = plistlib.loads(self.cat_bytes(info_plist))
      if version_string := plist.get("CFBundleShortVersionString"):
        display_name = plist.get("CFBundleDisplayName")
        if not display_name:
          # Fallback. Apps like Firefox have no CFBundleDisplayName.
          display_name = plist.get("CFBundleName")
        return f"{display_name} {version_string}"

    # Backup solution use the binary (not the .app bundle) with --version.
    maybe_bin_path: pth.AnyPath | None = app_or_bin
    if app_or_bin.suffix == ".app":
      maybe_bin_path = self.search_binary(app_or_bin)
    if not maybe_bin_path:
      raise ValueError(f"Could not extract app version: {app_or_bin}")
    try:
      return self.sh_stdout(maybe_bin_path, "--version").strip()
    except SubprocessError as e:
      raise ValueError(f"Could not extract app version: {app_or_bin}") from e

  def exec_apple_script(self, script: str, *args: str) -> str:
    if args:
      script = f"""on run argv
        {script.strip()}
      end run"""
    return self.sh_stdout("/usr/bin/osascript", "-e", script, *args)

  def foreground_process(self) -> Optional[dict[str, Any]]:
    foreground_process_info = self.sh_stdout("lsappinfo", "front").strip()
    if not foreground_process_info:
      return None
    foreground_info = self.sh_stdout("lsappinfo", "info", "-only", "pid",
                                     foreground_process_info).strip()
    foreground_info_split = foreground_info.split("=")

    pid = None

    if len(foreground_info_split) == 2:
      pid = foreground_info_split[1]
    else:
      # On macOS 14.0 Beta, "lsappinfo info" returns an empty result. Fall back
      # to parsing the output of "lsappinfo list" to obtain the front app's
      # info.
      app_list = self.sh_stdout("lsappinfo", "list")
      found_front_app = False
      for app_list_line in app_list.splitlines():
        if re.match(self.LSAPPINFO_IN_FRONT_LINE_RE, app_list_line):
          found_front_app = True
        elif found_front_app:
          match = re.match(self.LSAPPINFO_PID_LINE_RE, app_list_line)
          if match:
            pid = match.group(1)
            break

    if pid and pid.isdigit():
      return psutil.Process(int(pid)).as_dict()

    return None

  @contextlib.contextmanager
  def wakelock(self) -> Iterator[None]:
    process: subprocess.Popen = self.popen("caffeinate", "-imdsu")
    atexit.register(process.kill)
    try:
      yield
    finally:
      atexit.unregister(process.kill)
      process.kill()

  def check_system_monitoring(self, disable: bool = False) -> bool:
    return self.check_crowdstrike(disable)

  def check_autobrightness(self) -> bool:
    output = self.sh_stdout("system_profiler", "SPDisplaysDataType",
                            "-json").strip()
    data = json.loads(output)
    if spdisplays_data := data.get("SPDisplaysDataType"):
      for data in spdisplays_data:
        if spdisplays_ndrvs := data.get("spdisplays_ndrvs"):
          for display in spdisplays_ndrvs:
            if auto_brightness := display.get("spdisplays_ambient_brightness"):
              return auto_brightness == "spdisplays_yes"
        raise ValueError(
            "Could not find 'spdisplays_ndrvs' from SPDisplaysDataType. "
            f"Output={output}")
    raise ValueError("Could not get 'SPDisplaysDataType' form system profiler. "
                     f"Output={output}")

  def check_crowdstrike(self, disable: bool = False) -> bool:
    falconctl = self.path(
        "/Applications/Falcon.app/Contents/Resources/falconctl")
    if not self.exists(falconctl):
      logging.debug("You're fine, falconctl or %s are not installed.",
                    falconctl)
      return True
    if not disable:
      for process in self.processes(attrs=["exe"]):
        exe = process["exe"]
        if exe and exe.endswith("/com.crowdstrike.falcon.Agent"):
          return False
      return True
    try:
      logging.warning("Checking falcon sensor status:")
      status = self.sh_stdout("sudo", falconctl, "stats", "agent_info")
    except SubprocessError as e:
      logging.debug("Could not probe falconctl, assuming it's not running: %s",
                    e)
      return True
    if "operational: true" not in status:
      # Early return if not running, no need to disable the sensor.
      return True
    # Try disabling the process
    logging.warning("Disabling crowdstrike monitoring:")
    self.sh("sudo", falconctl, "unload")
    return True

  def _get_main_display(self) -> tuple[ctypes.CDLL, Any]:
    assert self.is_local, "Operation not supported on remote platforms"
    core_graphics = ctypes.CDLL(
        "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")
    main_display = core_graphics.CGMainDisplayID()
    return main_display, core_graphics

  def _get_display_service(self) -> tuple[ctypes.CDLL, Any]:
    main_display, _ = self._get_main_display()
    display_services = ctypes.CDLL(
        "/System/Library/PrivateFrameworks/DisplayServices.framework"
        "/DisplayServices")
    display_services.DisplayServicesSetBrightness.argtypes = [
        ctypes.c_int, ctypes.c_float
    ]
    display_services.DisplayServicesGetBrightness.argtypes = [
        ctypes.c_int, ctypes.POINTER(ctypes.c_float)
    ]
    return display_services, main_display

  def set_main_display_brightness(self, brightness_level: int) -> None:
    """Sets the main display brightness at the specified percentage by
    brightness_level.

    This function imitates the open-source "brightness" tool at
    https://github.com/nriley/brightness.
    Since the benchmark doesn't care about older MacOSen, multiple displays
    or other complications that tool has to consider, setting the brightness
    level boils down to calling this function for the main display.

    Args:
      brightness_level: Percentage at which we want to set screen brightness.

    Raises:
      AssertionError: An error occurred when we tried to set the brightness
    """
    display_services, main_display = self._get_display_service()
    ret = display_services.DisplayServicesSetBrightness(main_display,
                                                        brightness_level / 100)
    assert ret == 0

  def get_main_display_brightness(self) -> int:
    """Gets the current brightness level of the main display .

    This function imitates the open-source "brightness" tool at
    https://github.com/nriley/brightness.
    Since the benchmark doesn't care about older MacOSen, multiple displays
    or other complications that tool has to consider, setting the brightness
    level boils down to calling this function for the main display.

    Returns:
      An int of the current percentage value of the main screen brightness

    Raises:
      AssertionError: An error occurred when we tried to set the brightness
    """

    display_services, main_display = self._get_display_service()
    display_brightness = ctypes.c_float()
    ret = display_services.DisplayServicesGetBrightness(
        main_display, ctypes.byref(display_brightness))
    assert ret == 0, f"ret={ret}, display_brightness={display_brightness}"
    return round(display_brightness.value * 100)

  def _core_graphics_types(self, core_graphics) -> None:  # noqa: ANN001
    # https://developer.apple.com/documentation/coregraphics/1455620-cgmaindisplayid?language=objc
    core_graphics.CGMainDisplayID.argtypes = ()
    core_graphics.CGMainDisplayID.restype = ctypes.c_uint32
    # https://developer.apple.com/documentation/coregraphics/1454099-cgdisplaycopydisplaymode?language=objc
    core_graphics.CGDisplayCopyDisplayMode.argtypes = (ctypes.c_uint32,)
    core_graphics.CGDisplayCopyDisplayMode.restype = ctypes.c_void_p
    # https://developer.apple.com/documentation/coregraphics/1455537-cgdisplaycopyalldisplaymodes?language=objc
    core_graphics.CGDisplayCopyAllDisplayModes.argtypes = (ctypes.c_uint32,
                                                           ctypes.c_void_p)
    core_graphics.CGDisplayCopyAllDisplayModes.restype = ctypes.c_void_p
    # https://developer.apple.com/documentation/coregraphics/1454442-cgdisplaymodegetwidth?language=objc
    core_graphics.CGDisplayModeGetWidth.argtypes = (ctypes.c_void_p,)
    core_graphics.CGDisplayModeGetWidth.restype = ctypes.c_size_t
    # https://developer.apple.com/documentation/coregraphics/1455380-cgdisplaymodegetheight?language=objc
    core_graphics.CGDisplayModeGetHeight.argtypes = (ctypes.c_void_p,)
    core_graphics.CGDisplayModeGetHeight.restype = ctypes.c_size_t
    # https://developer.apple.com/documentation/coregraphics/1454661-cgdisplaymodegetrefreshrate?language=objc
    core_graphics.CGDisplayModeGetRefreshRate.argtypes = (ctypes.c_void_p,)
    core_graphics.CGDisplayModeGetRefreshRate.restype = ctypes.c_double
    # https://developer.apple.com/documentation/coregraphics/1454760-cgdisplaysetdisplaymode?language=objc
    core_graphics.CGDisplaySetDisplayMode.argtypes = (ctypes.c_uint32,
                                                      ctypes.c_void_p)
    core_graphics.CGDisplaySetDisplayMode.restype = ctypes.c_int32

  def _core_foundation_types(self, core_foundation) -> None:  # noqa: ANN001
    # https://developer.apple.com/documentation/corefoundation/1388772-cfarraygetcount?language=objc
    core_foundation.CFArrayGetCount.argtypes = (ctypes.c_void_p,)
    core_foundation.CFArrayGetCount.restype = ctypes.c_long
    # https://developer.apple.com/documentation/corefoundation/1388767-cfarraygetvalueatindex?language=objc
    core_foundation.CFArrayGetValueAtIndex.argtypes = (ctypes.c_void_p,
                                                       ctypes.c_long)
    core_foundation.CFArrayGetValueAtIndex.restype = ctypes.c_void_p
    # https://developer.apple.com/documentation/corefoundation/cfdictionarycreate(_:_:_:_:_:_:)?language=objc
    core_foundation.CFDictionaryCreate.argtypes = (
        ctypes.c_void_p,  # allocator
        ctypes.c_void_p,  # **keys
        ctypes.c_void_p,  # **values
        ctypes.c_long,  # numValues
        ctypes.c_void_p,  # *keyCallBacks
        ctypes.c_void_p,  # *valueCallBacks
    )
    core_foundation.CFDictionaryCreate.restype = ctypes.c_void_p
    # https://developer.apple.com/documentation/corefoundation/1521153-cfrelease/
    core_foundation.CFRelease.argtypes = (ctypes.c_void_p,)

  def set_display_refresh_rate(self,
                               refresh_rate: int,
                               retry: int = 3) -> tuple[bool, str]:
    """Sets the refresh rate if the main display supports it.

    This function uses CoreGraphics and CoreFoundtation libraries:
    https://developer.apple.com/documentation/coregraphics/
    https://developer.apple.com/documentation/corefoundation
    If this function detects a display mode with the same width and height, it
    sets the refresh rate to the requested rate.

    Args:
      refresh_rate: Target display refresh rate to set.
      retry: How many times to try if something goes wrong in setting the rate.

    Returns:
      A tuple of boolean values to indicate success or failure, and a message.
    """
    refresh_rate = NumberParser.int_range(30, 240)(refresh_rate)
    # Getting the current main display info.
    main_display, core_graphics = self._get_main_display()
    self._core_graphics_types(core_graphics)

    # Get the current refresh rate and verify if it needs to be set.
    display_mode = core_graphics.CGDisplayCopyDisplayMode(main_display)
    main_refresh_rate = core_graphics.CGDisplayModeGetRefreshRate(display_mode)
    if main_refresh_rate == refresh_rate:
      return True, f"The display refresh rate is already {refresh_rate}Hz"
    main_width = round(core_graphics.CGDisplayModeGetWidth(display_mode))
    main_height = round(core_graphics.CGDisplayModeGetHeight(display_mode))
    log_msg = (f"\nMain display: ID={main_display}, "
               f"width={main_width}, height={main_height}, "
               f"main_refresh_rate={refresh_rate}, "
               f"refresh_rate={refresh_rate}")

    core_foundation = ctypes.CDLL(ctypes.util.find_library("CoreFoundation"))
    self._core_foundation_types(core_foundation)

    # Finding all available display modes.
    keys = (ctypes.c_void_p * 1)()
    keys[0] = ctypes.c_void_p.in_dll(
        core_graphics, "kCGDisplayShowDuplicateLowResolutionModes")
    values = (ctypes.c_void_p * 1)()
    values[0] = ctypes.c_void_p.in_dll(core_foundation, "kCFBooleanTrue")
    options = core_foundation.CFDictionaryCreate(None, keys, values, 1, None,
                                                 None)
    display_modes = core_graphics.CGDisplayCopyAllDisplayModes(
        main_display, options)

    # Finding a display with original size and capable of the refresh rate.
    set_mode = None
    for idx in range(core_foundation.CFArrayGetCount(display_modes)):
      display_mode = core_foundation.CFArrayGetValueAtIndex(display_modes, idx)
      width = round(core_graphics.CGDisplayModeGetWidth(display_mode))
      height = round(core_graphics.CGDisplayModeGetHeight(display_mode))
      rate = int(core_graphics.CGDisplayModeGetRefreshRate(display_mode))
      log_msg += (f"\nDetected: display_mode={display_mode}, "
                  f"width={width}, height={height}, "
                  f"refresh_rate={rate}")
      if (main_width == width and main_height == height and
          refresh_rate == rate):
        set_mode = display_mode
        break

    # Set the refresh rate if the suitable display mode is found.
    if set_mode is not None:
      for _ in range(retry):
        core_graphics.CGDisplaySetDisplayMode(main_display, set_mode, None)
        rate = int(core_graphics.CGDisplayModeGetRefreshRate(display_mode))
        if refresh_rate == rate:
          return True, f"The refresh rate was successfully set!\n{log_msg}"
        log_msg += "\nFailed to set the refresh rate!"
        self.sleep(2)
    else:
      log_msg += "\nFailed to find a match for display size and refresh rate!"

    return False, log_msg

  def screenshot(self, result_path: pth.AnyPath) -> None:
    self.sh("screencapture", "-x", result_path)

  @override
  def is_port_used(self, port: int) -> bool:
    # We need a custom solution for macos:
    # - psutil.net_connections requires root access on macos
    # - 'ss' is not available by default on macos
    # This is a semi-ideal solution as it creates a temporary local server.
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
      return s.connect_ex(("localhost", port)) == 0

  @override
  def last_modified(self, path: pth.AnyPathLike) -> float:
    if self.is_local:
      return super().last_modified(path)
    # Get seconds since epoch
    return float(self.sh_stdout("stat", "-f", "%m", self.path(path)))
